﻿////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// <copyright>Copyright 2008-2011 Andreas Huber Doenni</copyright>
// Distributed under the Microsoft Public License (Ms-PL).
// See accompanying file License.txt or copy at http://mews.codeplex.com/license.
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

namespace Mews.Net
{
    using System;
    using System.Collections.Generic;
    using System.Diagnostics.CodeAnalysis;
    using System.IO;
    using System.Net.Sockets;
    using System.Text;

    using Mews.IO;

    /// <summary>Is the base for all classes that represent a client connection to a server.</summary>
    /// <typeparam name="TClient">The most-derived type subclassing this class.</typeparam>
    /// <remarks>More specifically, this class provides the means to communicate with servers that require the
    /// following:
    /// <list type="bullet">
    /// <item>A bi-directional TCP connection.</item>
    /// <item>Each server response is a direct result of a command sent by the client, or the initial connection of the
    /// client.</item>
    /// <item>If pipelining (see <a href="http://www.ietf.org/rfc/rfc3977.txt">RFC 3977</a> 3.5.) is allowed then
    /// commands are always answered in the order they were sent.</item>
    /// <item>For anything sent or received, a line break is always encoded with a "\r\n" sequence.</item>
    /// <item>For anything sent or received, the octets '\r' and '\n' must not appear outside line ending sequences.
    /// </item>
    /// <item>A single-line command or response is always terminated with a "\r\n" sequence.</item>
    /// <item>A multi-line command or response is always terminated with a "\r\n.\r\n" sequence. If any original line
    /// of a multi-line data block starts with the termination octet '.', then that line must be dot-stuffed before
    /// transmission and the stuffing must be undone after reception, see
    /// <a href="http://www.ietf.org/rfc/rfc3977.txt">RFC 3977</a> 3.1.1.</item>
    /// </list>
    /// </remarks>
    /// <threadsafety static="true" instance="false"/>
    public abstract class ClientBase<TClient> : IClient<TClient> where TClient : IClient<TClient>
    {
        private TcpClient tcpClient;
        private int timeout = 30000;
        private int minPipelineLength = 64;
        private int maxPipelineLength = 128;
        private readonly ICommand<TClient> disconnectCommand;
        private readonly ReadBuffer readBuffer;
        private readonly WriteBuffer writeBuffer;
        private readonly LinkedList<ICommand<TClient>> commandQueue = new LinkedList<ICommand<TClient>>();
        private readonly LinkedList<ICommand<TClient>> commandPipeline = new LinkedList<ICommand<TClient>>();
        private CommandStream currentCommandStream;
        private ResponseStream currentResponseStream;

        ////////////////////////////////////////////////////////////////////////////////////////////////////////////////

        /// <summary>Asks the server to close the session and then closes the connection.</summary>
        public void Dispose()
        {
            this.Dispose(true);
            GC.SuppressFinalize(this);
        }

        /// <summary>See <see cref="IClient{T}.EnqueueCommand"/>.</summary>
        public void EnqueueCommand(ICommand<TClient> command)
        {
            this.AssertNotDisposed();
            this.commandQueue.AddLast(command);
        }

        /// <summary>See <see cref="IClient{T}.GetResponse"/>.</summary>
        /// <remarks>
        /// <para>During any call to <see cref="GetResponse"/>, right before the new response is received, the current
        /// pipeline length is compared to <see cref="MinPipelineLength"/>. If the current length is less than or equal
        /// to the minimum, remaining queued commands are sent to the server and put into the command pipeline until the
        /// pipeline length is equal to <see cref="MaxPipelineLength"/> + 1. If the current length is greater than the
        /// minimum, no queued commands are sent to the server.</para>
        /// <para>If <see cref="MinPipelineLength"/> equals 0 and <see cref="MaxPipelineLength"/> equals 0 then
        /// commands and responses are exchanged in a lock-step manner. That is, no command is sent before the response
        /// to the previous command has been received in full.</para>
        /// </remarks>
        public IResponse GetResponse()
        {
            this.AssertNotDisposed();

            if ((this.commandPipeline.First == null) && (this.commandQueue.First == null))
            {
                throw new InvalidOperationException("No response available.");
            }

            this.ExecuteQueuedCommands();
            ICommand<TClient> command = this.commandPipeline.First.Value;
            this.commandPipeline.RemoveFirst();
            return command.ReceiveResponse(
                (initialLineEncoding, maxInitialLineWordCount, successResponseCodes) => this.CreateResponseStream(
                    command.IsMultilineResponse,
                    command.ThrowOnError,
                    initialLineEncoding,
                    maxInitialLineWordCount,
                    successResponseCodes),
                this.CreateCommandStream);
        }

        /// <summary>See <see cref="IClient{T}.PendingCommandsCount"/>.</summary>
        public int PendingCommandsCount
        {
            get { return this.commandQueue.Count + this.commandPipeline.Count; }
        }

        /// <summary>Gets or sets the maximum amount of time, in milliseconds, a send or receive operation may take
        /// before it is aborted and an appropriate exception is thrown.</summary>
        /// <remarks>Set to a negative number to wait indefinitely. Default is 30000.</remarks>
        public int Timeout
        {
            get { return this.timeout; }
            set { this.timeout = value; }
        }

        /// <summary>Gets or sets the minimum pipeline length, see <see cref="GetResponse"/> for more information.
        /// </summary>
        /// <exception cref="ArgumentOutOfRangeException">Set a value that is less than 0 or greater than
        /// <see cref="MaxPipelineLength"/>.</exception>
        public int MinPipelineLength
        {
            get
            {
                return this.minPipelineLength;
            }

            set
            {
                if (value < 0)
                {
                    throw new ArgumentOutOfRangeException("value", ExceptionMessages.NonnegativeNumberRequired);
                }

                if (value > this.maxPipelineLength)
                {
                    throw new ArgumentOutOfRangeException(
                        "value", "Must be less than or equal to MaxPipelineLength.");
                }

                this.minPipelineLength = value;
            }
        }

        /// <summary>Gets or sets the maximum pipeline length, see <see cref="GetResponse"/> for more information.
        /// </summary>
        /// <exception cref="ArgumentOutOfRangeException">Set a value that is less than <see cref="MinPipelineLength"/>.
        /// </exception>
        public int MaxPipelineLength
        {
            get
            {
                return this.maxPipelineLength;
            }

            set
            {
                if (value < this.minPipelineLength)
                {
                    throw new ArgumentOutOfRangeException(
                        "value", "Must be greater than or equal to MinPipelineLength.");
                }

                this.maxPipelineLength = value;
            }
        }

        ////////////////////////////////////////////////////////////////////////////////////////////////////////////////

        internal ClientBase(string hostName, int port, ICommand<TClient> disconnectCommand)
        {
            try
            {
                this.tcpClient = new TcpClient(hostName, port);
                this.tcpClient.LingerState = new LingerOption(true, 1); // So that QUIT can be sent in Dispose
                this.disconnectCommand = disconnectCommand;
                var fixStream = new FixLineEndingsStream(
                    new ReadBuffer(this.TcpReceive, this.tcpClient.ReceiveBufferSize),
                    new WriteBuffer(this.TcpSend, () => { }, this.tcpClient.SendBufferSize));
                this.readBuffer = new ReadBuffer(fixStream.Read, Settings.InMemoryStreamBufferSize);
                this.writeBuffer = new WriteBuffer(fixStream.Write, fixStream.Flush, Settings.InMemoryStreamBufferSize);
            }
            catch (Exception ex)
            {
                this.Dispose();

                if (ex is SocketException)
                {
                    throw new IOException("Unable to connect to " + hostName + ":" + port, ex);
                }

                throw;
            }
        }

        ////////////////////////////////////////////////////////////////////////////////////////////////////////////////

        /// <summary>Gets a value indicating whether <see cref="Dispose()"/> has been called.</summary>
        protected bool IsDisposed
        {
            get { return this.tcpClient == null; }
        }

        ////////////////////////////////////////////////////////////////////////////////////////////////////////////////

        private int TcpReceive(byte[] buffer, int offset, int count)
        {
            try
            {
                if (!this.tcpClient.Client.Poll(this.timeout * 1000, SelectMode.SelectRead))
                {
                    throw new SocketException((int)SocketError.TimedOut);
                }

                int received = this.tcpClient.Client.Receive(buffer, offset, count, SocketFlags.None);

                if (received == 0)
                {
                    // When the remote host has shut down his socket and all sent data has already been received then
                    // Receive will return 0. The calling code expected at least one byte, otherwise this method would
                    // not have been called. So, we best throw an exception here.
                    throw new SocketException((int)SocketError.ConnectionReset);
                }

                return received;
            }
            catch (SocketException ex)
            {
                throw new IOException("Unable to read data from the transport connection.", ex);
            }
        }

        private void TcpSend(byte[] buffer, int offset, int count)
        {
            try
            {
                int deadline = Environment.TickCount + this.timeout;
                int currentTimeout;
                int sent;

                while (count > 0)
                {
                    currentTimeout = deadline - Environment.TickCount;

                    if ((currentTimeout <= 0) ||
                        !this.tcpClient.Client.Poll(currentTimeout * 1000, SelectMode.SelectWrite))
                    {
                        throw new SocketException((int)SocketError.TimedOut);
                    }

                    sent = this.tcpClient.Client.Send(buffer, offset, count, SocketFlags.None);
                    offset += sent;
                    count -= sent;
                }
            }
            catch (SocketException ex)
            {
                throw new IOException("Unable to write data to the transport connection.", ex);
            }
        }

        private void AssertNotDisposed()
        {
            if (this.IsDisposed)
            {
                throw new ObjectDisposedException(this.ToString());
            }
        }

        [SuppressMessage("Microsoft.Usage", "CA2213:DisposableFieldsShouldBeDisposed", Justification = "Disposing these fields may write additional data.")]
        [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Dispose must never throw.")]
        private void Dispose(bool disposing)
        {
            try
            {
                if (disposing && !this.IsDisposed)
                {
                    if (this.tcpClient.Client.Connected)
                    {
                        this.commandQueue.Clear();
                        this.commandPipeline.Clear();
                        this.EnqueueCommand(this.disconnectCommand);
                        this.ExecuteQueuedCommands();
                        this.commandPipeline.RemoveFirst(); // So that PendingCommandsCount == 0 after we return
                        this.writeBuffer.Flush();
                    }

                    this.tcpClient.Close();
                }
            }
            catch
            {
            }
            finally
            {
                this.tcpClient = null;
            }
        }

        private void ExecuteQueuedCommands()
        {
            // Right after this method returns, the oldest command in the pipeline is removed. This is why we work with <= instead
            // of < here
            if (this.commandPipeline.Count <= this.minPipelineLength)
            {
                CreateCommandStreamCallback createCommandStreamCallback = this.CreateCommandStream;

                while ((this.commandQueue.First != null) && (this.commandPipeline.Count <= this.maxPipelineLength) &&
                    ((this.commandPipeline.Last == null) || this.commandPipeline.Last.Value.IsPipelinable))
                {
                    LinkedListNode<ICommand<TClient>> command = this.commandQueue.First;
                    this.commandQueue.RemoveFirst();
                    command.Value.SendCommand(createCommandStreamCallback);
                    this.commandPipeline.AddLast(command);
                }
            }
        }

        private CommandStream CreateCommandStream(bool isMultiline)
        {
            this.DisposeStreams();
            this.currentCommandStream = new CommandStream(this.writeBuffer, isMultiline);
            return this.currentCommandStream;
        }

        private ResponseStream CreateResponseStream(
            Func<IList<string>, bool> isMultiLineCallback,
            Action<IList<string>> throwOnErrorCallback,
            Encoding initialLineEncoding,
            int maxInitialLineWordCount,
            string[] successResponseCodes)
        {
            if (successResponseCodes == null)
            {
                throw new ArgumentNullException("successResponseCodes");
            }

            this.DisposeStreams();
            this.writeBuffer.Flush();
            this.currentResponseStream =
                new ResponseStream(this.readBuffer, isMultiLineCallback, initialLineEncoding, maxInitialLineWordCount);
            IList<string> initialLineWords = this.currentResponseStream.InitialLineWords;

            try
            {
                throwOnErrorCallback(initialLineWords);

                if (!((IList<string>)successResponseCodes).Contains(initialLineWords[0]))
                {
                    throw new ExceptionHelper(initialLineWords).CreateException<UnexpectedResponseException>();
                }

                return this.currentResponseStream;
            }
            catch
            {
                this.currentResponseStream.Dispose();
                this.currentResponseStream = null;
                throw;
            }
        }

        private void DisposeStreams()
        {
            if (this.currentCommandStream != null)
            {
                this.currentCommandStream.Dispose();
                this.currentCommandStream = null;
            }

            if (this.currentResponseStream != null)
            {
                this.currentResponseStream.Dispose();
                this.currentResponseStream = null;
            }
        }

        // Copied from desktop framework, because CF lacks this enum
        private enum SocketError
        {
            ConnectionReset = 10054,
            TimedOut = 10060
        }
    }
}
